{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Driving through the maze of traffic cones - - Live Demo\n",
    "\n",
    "In this notebook we'll use the model which we trained to detect whether the robot is ``free`` or ``blocked`` and what direction (``left`` or ``right``) it should spin to navigate through the rows of traffic cones.  \n",
    "\n",
    "## Load the trained model\n",
    "\n",
    "Upload the model into this notebook's directory ``traffic_cones_driving`` by using the Jupyter Lab upload tool. Once that's finished there should be a file named ``best_model_cones.pth`` in this notebook's directory (``traffic_cones_driving``).  \n",
    "\n",
    "> Please make sure the file has uploaded fully before calling the next cell\n",
    "\n",
    "Execute the code below to initialize the PyTorch model.  This should look very familiar from the training notebook."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "metadata": {},
   "outputs": [],
   "source": [
    "import torch\n",
    "import torchvision\n",
    "\n",
    "model = torchvision.models.alexnet(pretrained=False)\n",
    "model.classifier[6] = torch.nn.Linear(model.classifier[6].in_features, 4)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Next, load the trained weights from the ``best_model.pth`` file that you uploaded"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "metadata": {},
   "outputs": [],
   "source": [
    "model.load_state_dict(torch.load('best_model_cones.pth'))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Currently, the model weights are located on the CPU memory execute the code below to transfer to the GPU device."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "metadata": {},
   "outputs": [],
   "source": [
    "device = torch.device('cuda')\n",
    "model = model.to(device)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Create the preprocessing function\n",
    "\n",
    "We have now loaded our model, but there's a slight issue.  The format that we trained our model doesnt *exactly* match the format of the camera.  To do that, \n",
    "we need to do some *preprocessing*.  This involves the following steps\n",
    "\n",
    "1. Convert from BGR to RGB\n",
    "2. Convert from HWC layout to CHW layout\n",
    "3. Normalize using same parameters as we did during training (our camera provides values in [0, 255] range and training loaded images in [0, 1] range so we need to scale by 255.0\n",
    "4. Transfer the data from CPU memory to GPU memory\n",
    "5. Add a batch dimension"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "metadata": {},
   "outputs": [],
   "source": [
    "import cv2\n",
    "import numpy as np\n",
    "\n",
    "mean = 255.0 * np.array([0.485, 0.456, 0.406])\n",
    "stdev = 255.0 * np.array([0.229, 0.224, 0.225])\n",
    "\n",
    "normalize = torchvision.transforms.Normalize(mean, stdev)\n",
    "\n",
    "def preprocess(camera_value):\n",
    "    global device, normalize\n",
    "    x = camera_value\n",
    "    x = cv2.cvtColor(x, cv2.COLOR_BGR2RGB)\n",
    "    x = x.transpose((2, 0, 1))\n",
    "    x = torch.from_numpy(x).float()\n",
    "    x = normalize(x)\n",
    "    x = x.to(device)\n",
    "    x = x[None, ...]\n",
    "    return x"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Great! We've now defined our pre-processing function which can convert images from the camera format to the neural network input format.\n",
    "\n",
    "Now, let's start and display our camera.  You should be pretty familiar with this by now."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "application/vnd.jupyter.widget-view+json": {
       "model_id": "0bc947e218d943a3b4e1a5d938e691b1",
       "version_major": 2,
       "version_minor": 0
      },
      "text/plain": [
       "HBox(children=(Image(value=b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x00\\x00\\x01\\x00\\x01\\x00\\x00\\xff\\xdb\\x00C…"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "import traitlets\n",
    "from IPython.display import display\n",
    "import ipywidgets.widgets as widgets\n",
    "from jetbot import Camera, bgr8_to_jpeg\n",
    "\n",
    "camera = Camera.instance(width=224, height=224)\n",
    "image = widgets.Image(format='jpeg', width=224, height=224)\n",
    "\n",
    "camera_link = traitlets.dlink((camera, 'value'), (image, 'value'), transform=bgr8_to_jpeg)\n",
    "\n",
    "display(widgets.HBox([image]))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We'll also create our robot instance which we'll need to drive the motors."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "metadata": {},
   "outputs": [],
   "source": [
    "from jetbot import Robot\n",
    "\n",
    "robot = Robot()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Next, we will write function which will collect frame-by-frame images with telementry (output stats and actions) for further analysis, debugging and controller tuning\n",
    "> To avoid possible confusion - the folder *images* stores the camera snapshots collected during the robot run. This is NOT the folder with training images. \n",
    "> \n",
    "> Also, the location of On-Screen_Display (OSD) messages with telemetry data is optimized for default (224 x 224 pixels) image size"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Directory not created becasue they already exist\n"
     ]
    }
   ],
   "source": [
    "import glob\n",
    "import os\n",
    "from PIL import Image, ImageFont, ImageDraw\n",
    "\n",
    "font = ImageFont.truetype(\"/usr/share/fonts/dejavu/DejaVuSans.ttf\", 20)\n",
    "font_probs = ImageFont.truetype(\"/usr/share/fonts/dejavu/DejaVuSans.ttf\", 14)\n",
    "path_to_img_folder = 'images'\n",
    "\n",
    "# we have this \"try/except\" statement because these next functions can throw an error if the directories exist already\n",
    "try:\n",
    "    os.makedirs(path_to_img_folder)\n",
    "except FileExistsError:\n",
    "    print('Directory not created becasue they already exist')\n",
    "\n",
    "def save_frames_with_telemetry(raw_image,current_probs,exploration,action,n_frames_stuck):\n",
    "    prob_free,prob_left,prob_right,prob_blocked = current_probs\n",
    "    # save image with telemetry for performance analysis (On-screen display data is for for 224x224 frame size) \n",
    "    img_file_nm = path_to_img_folder + '/img' + str(int(100.*time.time())) + '.jpeg'\n",
    "    img = Image.fromarray(raw_image)\n",
    "    \n",
    "    draw = ImageDraw.Draw(img)\n",
    "    draw.text((5,0),  \"F\", (255,255,0), font=font)\n",
    "    draw.text((5,20), \"L\", (255,255,0), font=font)\n",
    "    draw.text((5,40), \"R\", (255,255,0), font=font)\n",
    "    draw.text((5,60), \"B\", (255,255,0), font=font)\n",
    "    \n",
    "    def fill_color(prob,threshold,below_color=(0,255,0,255),above_color=(255,0,0,255)):\n",
    "        color = below_color\n",
    "        if prob > threshold:\n",
    "            color = above_color\n",
    "        return color\n",
    "    \n",
    "    draw.rectangle([20,5,  int(20+80*prob_free) ,  18], fill=fill_color(prob_free, 0.5), outline=None)\n",
    "    draw.rectangle([20,25, int(20+80*prob_left) ,  38], fill=fill_color(prob_left, 0.5), outline=None)\n",
    "    draw.rectangle([20,45, int(20+80*prob_right),  58], fill=fill_color(prob_right, 0.5), outline=None)\n",
    "    draw.rectangle([20,65, int(20+80*prob_blocked),78], fill=fill_color(prob_blocked, 0.5), outline=None)\n",
    "\n",
    "    draw.text((22,4),  str(round(prob_free,2)), (0,0,0), font=font_probs)\n",
    "    draw.text((22,24), str(round(prob_left,2)), (0,0,0), font=font_probs)\n",
    "    draw.text((22,44), str(round(prob_right,2)), (0,0,0), font=font_probs)\n",
    "    draw.text((22,64), str(round(prob_blocked,2)), (0,0,0), font=font_probs)\n",
    "    \n",
    "    # display frame number\n",
    "    draw.text((5,84), 'FRAME #' + str(frame_counter), (0,0,0), font=font_probs)\n",
    "\n",
    "    # display state of n_frames_stuck counter\n",
    "    if exploration:\n",
    "        draw.rectangle([5,104,170,119], fill=(255,0,0,255), outline=None)\n",
    "    draw.text((5,104), 'FRAMES STUCK: #' + str(n_frames_stuck), (0,0,0), font=font_probs)\n",
    "\n",
    "    action_displayed = action[0] + \" \" + str(action[1])\n",
    "    if (action[0] == \"FWRD\"):\n",
    "        action_displayed = action[0] + \" \" + str(action[1])\n",
    "        draw.text((105,4), action_displayed, (255,0,0), font=font_probs)\n",
    "    if (action[0] == \"LEFT\"):\n",
    "        draw.text((105,24), action_displayed, (255,0,0), font=font_probs)\n",
    "    if (action[0] == \"RGHT\"):\n",
    "        draw.text((105,44), action_displayed, (255,0,0), font=font_probs)\n",
    "    \n",
    "    draw = ImageDraw.Draw(img)\n",
    "    img.save(img_file_nm)    \n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Next, we'll create a function that will get called whenever the camera's value changes.  This function will do the following steps\n",
    "\n",
    "1. Pre-process the camera image\n",
    "2. Execute the neural network\n",
    "3. While the neural network output indicates we're blocked, we'll turn left, otherwise we go forward.\n",
    "4. (Optional) - save each frame on the disk along with telementry for further analysis, PD controller tuning and debugging "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "metadata": {
    "scrolled": true
   },
   "outputs": [],
   "source": [
    "import torch.nn.functional as F\n",
    "import time\n",
    "\n",
    "# Simple PD controller (Kp - proportional term, Kd - derivative term)\n",
    "Kp = 0.18\n",
    "Kd = 0.05\n",
    "\n",
    "frwd_value = 0.3                      # Default value to drive forward (0 = no action, 1 = full motor capacity)\n",
    "rot_value_when_exploring = 0.3        # Default value to spin to the right when robot is in exploration mode (0 = no action, 1 = full motor capacity)\n",
    "min_prob_free_to_drive_frwd = 0.25    # Min probability prob_free for robot to drive forward \n",
    "max_n_frames_stuck = 20               # Limit on the number of frames the robot is stuck for. Once this limit is reached, robot goes into exploration mode (makes large right turn)\n",
    "frame_counter = 0                     # Frame counter \n",
    "n_frames_stuck = 0                    # Initialize counter of the number of successive frames the robot is stuck for\n",
    "exploration = False                   # Initialize binary variable which determines if robot is in exploration mode (when True.) Used to mark the related frames with red background  \n",
    "data_log = []                         # Initialize the array whcih will store a history of telemetry readings and robot actions (for analysis and tuning)\n",
    "recent_detections = []                 # Initialize the array to store the last frame data\n",
    "\n",
    "def update(change):\n",
    "    global robot, frame_counter, n_frames_stuck, exploration\n",
    "    x = change['new'] \n",
    "    x = preprocess(x)\n",
    "    y = model(x)\n",
    "    \n",
    "    # apply the `softmax` function to normalize the output vector so it sums to 1 (which makes it a probability distribution)\n",
    "    y = F.softmax(y, dim=1)\n",
    "    \n",
    "    y = y.flatten()\n",
    "   \n",
    "    # extract probabilities of blocked, free, left and right\n",
    "    prob_blocked = float(y[0])\n",
    "    prob_free = float(y[1])\n",
    "    prob_left = float(y[2])\n",
    "    prob_right = float(y[3])\n",
    " \n",
    "    # update list of recent detections\n",
    "    while (len(recent_detections) >= 2):\n",
    "        recent_detections.pop(0)\n",
    "    recent_detections.append([prob_free,prob_left,prob_right,prob_blocked])\n",
    "    \n",
    "    # check if robot got stuck and update n_frames_stuck counter\n",
    "    if prob_free < min_prob_free_to_drive_frwd:\n",
    "        n_frames_stuck = n_frames_stuck + 1 \n",
    "    else:\n",
    "        n_frames_stuck = 0\n",
    "        \n",
    "    # calculate errors at times t (current) and t-1 (prev)    \n",
    "    # error(t) and error(t-1): prob_left-prob_right   \n",
    "    if len(recent_detections) == 2:\n",
    "        current_probs = recent_detections[1]\n",
    "        prev_probs = recent_detections[0]\n",
    "    else:\n",
    "        current_probs = [prob_free,prob_left,prob_right,prob_blocked]\n",
    "        prev_probs = current_probs\n",
    "                \n",
    "    # error = prob_left-prob_right        \n",
    "    current_error = current_probs[1] - current_probs[2]\n",
    "    prev_error    = prev_probs[1] - prev_probs[2]\n",
    "\n",
    "    # increment frame counter \n",
    "    frame_counter = frame_counter + 1\n",
    "    \n",
    "    # define functions which deterine (and return) robot actions\n",
    "    def forward(value):\n",
    "        robot.forward(value)\n",
    "        return (\"FWRD\",round(value,2))\n",
    "\n",
    "    def left(value):\n",
    "        robot.left(value)\n",
    "        return (\"LEFT\",round(value,2))\n",
    "\n",
    "    def right(value):\n",
    "        robot.right(value)\n",
    "        return (\"RGHT\",round(value,2))\n",
    "    \n",
    "    action = \"\"\n",
    "  \n",
    "    # estimate rotational value to turn left (if negative) or right (if positive)\n",
    "    # 0 = no action, 1 = full motor capacity)\n",
    "    rot_value = - Kp * current_error - Kd * (current_error - prev_error)\n",
    "    \n",
    "    # store propotional and differential controller components for frame-by-frame analysis\n",
    "    p_component = - Kp * current_error\n",
    "    d_component = - Kd * (current_error - prev_error)\n",
    "    \n",
    "    # initalize binary flag showinf if robot rotates \n",
    "    robot_rotates = False\n",
    "    \n",
    "    # action logic\n",
    "    # moving forward if there is no obstacles\n",
    "    if prob_free > min_prob_free_to_drive_frwd:\n",
    "        action = forward(frwd_value)\n",
    "\n",
    "    # turn left or right if robot is not blocked for a long time\n",
    "    elif n_frames_stuck < max_n_frames_stuck:\n",
    "        robot_rotates = True\n",
    "        if rot_value < 0.0:\n",
    "            action = left(-rot_value)\n",
    "        else:\n",
    "            action = right(rot_value)\n",
    "\n",
    "    # activate exploration mode - robot turns right by a large (45-90 degree) angle if it failed to move forward for [max_n_frames_stuck] recent frames\n",
    "    else:\n",
    "        exploration = True\n",
    "        robot_rotates = True\n",
    "        action = right(rot_value_when_exploring)\n",
    "        time.sleep(0.5)\n",
    "        n_frames_stuck = 0\n",
    "    \n",
    "    time.sleep(0.001)\n",
    "    \n",
    "    # save frames - images with telemetry and robot actions data \n",
    "    save_frames_with_telemetry(change['new'],current_probs,exploration,action,n_frames_stuck)\n",
    "      \n",
    "    # append frame's telemetry and robot action to the stored data \n",
    "    if not robot_rotates:\n",
    "        rot_value = 0.\n",
    "        p_component = 0.\n",
    "        d_component = 0.\n",
    "    if robot_rotates and exploration:\n",
    "        rot_value = rot_value_when_exploring\n",
    "        p_component = 0.\n",
    "        d_component = 0.\n",
    "    \n",
    "    last_frame_data = [frame_counter, round(prob_free,3), round(prob_left,3), round(prob_right,3), round(prob_blocked,3),\n",
    "                           action[0], action[1], round(rot_value,3), round(p_component,3), round(d_component,3), n_frames_stuck, exploration]\n",
    "    data_log.append(last_frame_data)\n",
    "    \n",
    "    # reset variables\n",
    "    exploration = False\n",
    "    robot_rotates = False"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 24,
   "metadata": {},
   "outputs": [],
   "source": [
    "update({'new': camera.value})  # we call the function once to initialize\n",
    "robot.stop()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We've created our neural network execution function, but now we need to attach it to the camera for processing. \n",
    "\n",
    "We accomplish that with the ``observe`` function.\n",
    "\n",
    "> WARNING: This code will move the robot!! Please make sure your robot has clearance.  The collision avoidance should work, but the neural\n",
    "> network is only as good as the data it's trained on!"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 25,
   "metadata": {},
   "outputs": [],
   "source": [
    "camera.observe(update, names='value')  # this attaches the 'update' function to the 'value' traitlet of our camera"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Great! If your robot is plugged in it should now be generating new commands with each new camera frame.  Perhaps start by placing your robot on the ground and seeing what it does when it reaches the cones.\n",
    "\n",
    "To stop this behavior, unattach this callback by executing the code below."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 26,
   "metadata": {
    "scrolled": true
   },
   "outputs": [],
   "source": [
    "camera.unobserve(update, names='value')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 27,
   "metadata": {},
   "outputs": [],
   "source": [
    "robot.stop()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Post-processing (Optional)\n",
    "The rest of the code is optional but very helpful if you want to imrpove performance of your robot. It writes a frame-by-frame history of probabilities and robot actions into csv file and creates two FPV videos - at slow (1 fps) and actual (15 fps) speed. I helps a lot to debug the code, fine-tune PD controller and understand where the robot fails and how to fix it (e.g. by modifying the robot actions logic or collecting additional images in failed scenarios to improve the model.) \n",
    "### Storing a history of model predictions and robot actions (data log) on the disk \n",
    "After you run the code below, you should see the file ``data_log.txt`` in ``traffic_cones_driving`` folder"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 28,
   "metadata": {},
   "outputs": [],
   "source": [
    "import csv\n",
    "\n",
    "def write_telemetry_to_csv(data, csv_output_name):\n",
    "    output_file_name = csv_output_name + '.csv'\n",
    "    with open(output_file_name, 'w') as outfile:\n",
    "        mywriter = csv.writer(outfile)\n",
    "     \n",
    "        # manually add header\n",
    "        mywriter.writerow(['frame_counter', 'prob_free', 'prob_left', 'prob_right', 'prob_blocked', 'direction', 'force', \n",
    "                           'rot_force', 'rot_force_p_component', 'rot_force_d_component', 'n_frames_stuck', 'exploration'])\n",
    "        for d in data:\n",
    "            mywriter.writerow(d)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 29,
   "metadata": {},
   "outputs": [],
   "source": [
    "write_telemetry_to_csv(data_log, 'data_log')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Make First Person View (FPV) videos with telemetry and robot actions on On-Screen Display (OSD)  \n",
    "> The following steps assumes that you ran function ``save_frames_with_telemetry`` above (thish is default) i.e. created folder ``images`` and store the snapshots there"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 30,
   "metadata": {},
   "outputs": [],
   "source": [
    "def create_ordered_img_array():\n",
    "    img_name_array = []\n",
    "    for filename in glob.glob('images/*.jpeg'):\n",
    "        img_name_array.append(filename)\n",
    "    img_name_array.sort()\n",
    "\n",
    "    img_array = []\n",
    "    for filename in img_name_array:    \n",
    "        img = cv2.imread(filename)\n",
    "        height, width, layers = img.shape\n",
    "        size = (width,height)\n",
    "        img_array.append(img)\n",
    "    #print(str(len(img_array))+' images were recorded')    \n",
    "    return  img_array   \n",
    "\n",
    "def make_video(video_file_name,array_of_images,fps=15,image_size = (224,224)):\n",
    "    out = cv2.VideoWriter(video_file_name,cv2.VideoWriter_fourcc(*'DIVX'), fps, image_size)\n",
    "    for i in range(len(array_of_images)):\n",
    "        out.write(array_of_images[i])\n",
    "    out.release()\n",
    "    \n",
    "def delete_images():\n",
    "    for image_file_name in os.listdir('images'):\n",
    "        if image_file_name.endswith(\".jpeg\"):\n",
    "            os.remove('images/' + image_file_name)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 31,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Create array of images\n",
    "img_array = create_ordered_img_array()\n",
    "\n",
    "# Make video with 1 fps\n",
    "make_video('video_1_fps.avi',img_array,1)\n",
    "\n",
    "# Make video with 15 fps (actual speed)\n",
    "make_video('video_15_fps.avi',img_array,15)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 32,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Delete all images (Clean image folder)\n",
    "delete_images()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "After the steps above you should see video files ``'video_1_fps.avi'`` and ``'video_15_fps.avi'`` in the folder ``traffic_cones_driving``.\n",
    "\n",
    "Finally, download ``data_log.csv``, ``'video_1_fps.avi'`` and ``'video_15_fps.avi'`` to your computer (using the Jupyter Lab file browser by right clicking on the file and selecting ``Download``) and then analyze/play them :-)\n",
    "\n",
    "> **If Chrome gives you security warning and does not allow to download the files, try to connect to JetBot via Internet Explorer and download the files from IE. It should work.**"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "(Optional) Perhaps you want the robot to run without streaming video to the browser.  You can unlink the camera as below."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 27,
   "metadata": {},
   "outputs": [],
   "source": [
    "camera_link.unlink()  # don't stream to browser (will still run camera)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "To continue streaming call the following."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 28,
   "metadata": {},
   "outputs": [],
   "source": [
    "camera_link.link()  # stream to browser (wont run camera)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Conclusion\n",
    "\n",
    "That's it for this live demo!  Hopefully you had some fun and your robot drove through the traffic cones and avoided collisions intelligently! \n",
    "\n",
    "If the JetBot wasn't performing well, try to spot where it fails. The beauty is that we can collect more data for these failure scenarios, modify the robot actions logic and tune the controller and the robot should get even better :-)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.6.7"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
